跳到主要内容

Go 中的 Slice

注意:使用 ... 可以省略指定长度,Go 会根据元素个数来计算长度,但是这样创建的还是数组而不是切片

arr := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Println("arr[2:6]=", arr[2:6]) //arr[2:6]= [2 3 4 5]
fmt.Println("arr[:6]=", arr[:6]) //arr[:6]= [0 1 2 3 4 5]
fmt.Println("arr[2:]=", arr[2:]) //arr[2:]= [2 3 4 5 6 7 8 9]
fmt.Println("arr[:]=", arr[:]) //arr[:]= [0 1 2 3 4 5 6 7 8 9]

切片的创建

如果初始定义数组时,不知道数组的长度,就需要 “动态数组”,在 Go 语言中这种结构叫 slice。

切片使用字面量初始化时和数组很像,但是不需要指定长度:

languages := []string{"Go", "Python", "C"}

或者使用内置函数 make 进行初始化,make 的函数定义如下:

func make([]T, len, cap) []T
// func make(t Type, size ...IntegerType) Type

第一个参数是 []T,T 即元素类型,第二个参数是长度 len,即初始化的切片拥有多少个元素,第三个参数是容量 cap,容量是可选参数,默认等于长度。使用内置函数 len 和 cap 可以得到切片的长度和容量,例如:

func printLenCap(nums []int) {
fmt.Printf("len: %d, cap: %d %v\n", len(nums), cap(nums), nums)
}

func TestSliceLenAndCap(t *testing.T) {
nums := []int{1}
printLenCap(nums) // len: 1, cap: 1 [1]
nums = append(nums, 2)
printLenCap(nums) // len: 2, cap: 2 [1 2]
nums = append(nums, 3)
printLenCap(nums) // len: 3, cap: 4 [1 2 3]
nums = append(nums, 3)
printLenCap(nums) // len: 4, cap: 4 [1 2 3 3]
}

有个快速创建大容量切片的方法:

func main() {
// 创建字符串切片
// 使用字符串 "test" 初始化第 100 个元素
slice := []string{99: "test"}
for i, s := range slice {
fmt.Println(i, s)
}
}

打印长度

func main() {
arr := []int{1, 2, 3, 4} // 动态数组,切片 slice
fmt.Println(len(arr))
}

切片的截取

slice 可以从一个数组或已经存在的 slice 中再次声明。切片的截取,arr[start:end] 是左闭右开的(但是注意,这样的拷贝也是引用传递的,修改了子数组会影响原始数组)

slice 通过 array[i:j] 来获取,其中 i 是数组的开始位置,j 是结束位置,但不包含 arr[j],它的长度是 j-i。

slice 默认开始位置是 0,结束位置为数组的长度,如:

//声明一个包含10个元素,且元素类型为type的数组
var ar =[10]{'a','b','c','d','e','f','g','h','i','j'}

//声明两个含有byte类型元素的slice
var a,b []byte

//a指向数组的第三个到第五个元素:ar[2]、ar[3]、ar[4]
a = ar[3:5]

//b指向数组的第四和第五个元素:ar[3]、ar[4]
b = ar[4:5]




var ar =[10]{'a','b','c','d','e','f','g','h','i','j'}
var aslice
aslice = ar[:] //等价于 aslice = arr[0:10]

s := []int{1, 2, 3}
s1 := s[0:2] // 1, 2

但是可以使用 copy 函数,可以用于深拷贝(拷贝数组的副本)

s := []int{1, 2, 3}
s2 := make([]int, 3)

// 把 s 中的值拷贝到 s2
copy(s2, s)

切片的原理

这个 make 有三个参数

// 开辟类型,长度,容量大小(不指定就是长度的大小)
arr = make([]int, 3, 5)

容量是当前切片已经预分配的内存能够容纳的元素个数,如果往切片中不断地增加新的元素。如果超过了当前切片的容量,就需要分配新的内存,并将当前切片所有的元素拷贝到新的内存块上。

因此为了减少内存的拷贝次数,容量在比较小的时候,一般是以 2 的倍数扩大的,例如 2 4 8 16 …,当达到 2048 时,会采取新的策略,避免申请内存过大,导致浪费。Go 语言源代码 runtime/slice.go 中是这么实现的,不同版本可能有所差异:

newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
// Check 0 < newcap to detect overflow
// and prevent an infinite loop.
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
// Set newcap to the requested cap when
// the newcap calculation overflowed.
if newcap <= 0 {
newcap = cap
}
}
}

即下面的这个图所示

上面的创建的 slice 如下图所示:

这个容量其实就和 Java 那个 ArrayList 初始容量是一样的,默认给你开辟这么大的空间,当容量不够之后(就像 ArrayList 那样可以添加元素),扩容大小为这个指定的容量大小

如下图,扩容后为 10

Slice 会缩容吗?

切片不会缩容,扩容后的数组空间,哪怕你不用,也是安安静静地占用着内存空间的,不会进行缩容;

切片不是纯引用类型

切片的数据结构 ⭐

  • 如果改变了原本的值,切片的值也会跟着变;
  • 如果改变了切片的值,原本的值也会跟着变;

所以有指针特性,其实 slice 的底层存储就是数组。

go 语言的 slice 是并不是纯引用类型,而是一种包含指针的聚合类型,类似

type IntSlice struct{
ptr *int
len int
cap int
}

切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组,因此切片操作是非常高效的。下面的例子展示了这个过程:

  • nums2 执行了一个切片操作 [2, 4),此时 nums 和 nums2 指向的是同一个数组。
  • nums2 增加 2 个元素 50 和 60 后,将底层数组下标 [4] 的值改为了 50,下标 [5] 的值置为 60。
  • 因为 nums 和 nums2 指向的是同一个数组,因此 nums 被修改为 [1, 2, 3, 4, 50]
nums := make([]int, 0, 8)
nums = append(nums, 1, 2, 3, 4, 5)
nums2 := nums[2:4]
printLenCap(nums) // len: 5, cap: 8 [1 2 3 4 5]
printLenCap(nums2) // len: 2, cap: 6 [3 4]

nums2 = append(nums2, 50, 60)
printLenCap(nums) // len: 5, cap: 8 [1 2 3 4 50]
printLenCap(nums2) // len: 4, cap: 6 [3 4 50 60]

切片使用上的坑

继续上面的,介绍这个子切片的坑

package main

import "fmt"

func main() {
a := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Printf("a:\t%v \n", a)

s1 := a[:]
s2 := s1[:]
fmt.Printf("s1:\t%v \n", s1)
fmt.Printf("s2:\t%v \n", s2)

fmt.Printf("\n-------------------------------\n")

a[9] = 10
s2[0] = 100
fmt.Printf("a:\t%v \n", a)
fmt.Printf("s1:\t%v \n", s1)
fmt.Printf("s2:\t%v \n", s2)
}

输出

a:      [0 1 2 3 4 5 6 7 8 9] 
s1: [0 1 2 3 4 5 6 7 8 9]
s2: [0 1 2 3 4 5 6 7 8 9]

-------------------------------
a: [100 1 2 3 4 5 6 7 8 10]
s1: [100 1 2 3 4 5 6 7 8 10]
s2: [100 1 2 3 4 5 6 7 8 10]

0全变100了 9全变10了

但是如果涉及到切片大小的变化就会发现这个 slice 不是纯粹的指针,它本质还是结构体(值传递),如下代码所示:

package main

import "fmt"

func add1(s []int, x int) []int {
s = append(s, x)
return s

}
func add2(s []int, x int) {
s = append(s, x)
}

func main() {
a := [...]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
fmt.Printf("a:\t %v \n", a)

s1 := a[5:8] //len=3 cap=5
fmt.Printf("s1:\t %v \n", s1)

add2(s1, 0)
fmt.Printf("a:\t %v \n", a)
fmt.Printf("s1:\t %v \n", s1)

add2(s1, 1)
fmt.Printf("a:\t %v \n", a)
fmt.Printf("s1:\t %v \n", s1)
}

输出

a:       [0 1 2 3 4 5 6 7 8 9] 
s1: [5 6 7]
a: [0 1 2 3 4 5 6 7 0 9]
s1: [5 6 7]
a: [0 1 2 3 4 5 6 7 1 9]
s1: [5 6 7]

数组的 8 依次变成了 0、1 但 slice s1 始终没变 说明 slice 并不是纯引用类型

所以 Slice 不能使用这种方式创建子数组

安全的创建子切片

怎么解决呢?防止共享数据的出现问题需要注意两条,只读和复制,或者统一归纳为不可变。

写法1:make出一个新 slice,然后先 copy 前缀到新数组上再追加:

func main() {
a := make([]int, 2, 2)
a[0], a[1] = 1, 2

b := make([]int, 1)
copy(b, a[0:1])
b = append(b, 3)

c := make([]int, 1)
copy(c, a[1:2])
c = append(c, 4)

fmt.Println(b, c)
}

写法2:利用 go 中 slice 的一个小众语法,a[0:1:1] (源[起始index,终止index,cap终止index]),强迫追加时复制到新数组。

func main() {
a := make([]int, 2, 2)
a[0], a[1] = 1, 2

b := append(a[0:1:1], 3)
c := append(a[1:2:2], 4)

fmt.Println(b, c)
}

切片操作及性能

Go 语言在 Github 上的官方 wiki - SliceTricks 介绍了切片常见的操作技巧。另一个项目 Go Slice Tricks Cheat Sheet 将这些操作以图片的形式呈现了出来,非常直观。

Copy 操作

Append 操作

切片有三个属性,指针(ptr)、长度(len) 和容量(cap)。append 时有两种场景:

  • 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间。
  • 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组。

因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够获得最好的性能。

Delete 操作

切片的底层是数组,因此删除意味着后面的元素需要逐个向前移位。每次删除的复杂度为 O(N),因此切片不合适大量随机删除的场景,这种场景下适合使用链表。

Delete(GC) 操作

删除后,将空余的位置置空 nil,有助于垃圾回收。

Insert 操作 ⭐

insert 和 append 类似。即在某个位置添加一个元素后,将该位置后面的元素再 append 回去。复杂度为 O(N)。因此,不适合大量随机插入的场景。

示例:在切片的 index 处添加元素

// 使用 ... 操作符
a = append(a[:index+1], a[index:]...)
a[index] = value

Filter 操作 ⭐

当原切片不会再被使用时,就地 filter 方式是比较推荐的,可以节省内存空间。

Push 操作(头插法)

在末尾追加元素,不考虑内存拷贝的情况,复杂度为 O(1)。

在头部追加元素,时间和空间复杂度均为 O(N),不推荐。

Pop 操作

尾部删除元素,复杂度 O(1)

头部删除元素,如果使用切片方式,复杂度为 O(1)。但是需要注意的是,底层数组没有发生改变,第 0 个位置的内存仍旧没有释放。如果有大量这样的操作,头部的内存会一直被占用。

排序操作

s := []int{4, 2, 3, 1}
sort.Ints(s)
fmt.Println(s) // [1 2 3 4]

自定义排序

family := []struct {
Name string
Age int
}{
{"Alice", 23},
{"David", 2},
{"Eve", 2},
{"Bob", 25},
}

// Sort by age, keeping original order or equal elements.
sort.SliceStable(family, func(i, j int) bool {
return family[i].Age < family[j].Age
})

fmt.Println(family) // [{David 2} {Eve 2} {Alice 23} {Bob 25}]

搜索操作

如果要搜索数据需要实现一个接口

type Interface interface {
// Len is the number of elements in the collection.
Len() int
// Less reports whether the element with
// index i should sort before the element with index j.
Less(i, j int) bool
// Swap swaps the elements with indexes i and j.
Swap(i, j int)
}

使用例子:

type Person struct {
Name string
Age int
}

// ByAge implements sort.Interface based on the Age field.
type ByAge []Person

func (a ByAge) Len() int { return len(a) }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }

func main() {
family := []Person{
{"Alice", 23},
{"Eve", 2},
{"Bob", 25},
}
sort.Sort(ByAge(family))
fmt.Println(family) // [{Eve 2} {Alice 23} {Bob 25}]
}

数组和切片陷阱

大量内存得不到释放

在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。

因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy 替代 re-slice。

func lastNumsBySlice(origin []int) []int {
return origin[len(origin)-2:]
}

func lastNumsByCopy(origin []int) []int {
result := make([]int, 2)
copy(result, origin[len(origin)-2:])
return result
}

上述两个函数的作用是一样的,取 origin 切片的最后 2 个元素。

  • 第一个函数直接在原切片基础上进行切片。
  • 第二个函数创建了一个新的切片,将 origin 的最后两个元素拷贝到新切片上,然后返回新切片。

数组是值传递

下面程序的输出是什么?

func foo(a [2]int) {
a[0] = 200
}

func main() {
a := [2]int{1, 2}
foo(a)
fmt.Println(a)
}

正确的输出是 [1 2],数组 a 没有发生改变。

  • 在 Go 语言中,数组是一种值类型,而且不同长度的数组属于不同的类型。例如 [2]int[20]int 属于不同的类型。
  • 当值类型作为参数传递时,参数是该值的一个拷贝,因此更改拷贝的值并不会影响原值。

所以为了避免数组的拷贝,提高性能,建议传递数组的指针作为参数,或者使用切片代替数组。

切片也需要传递指针 ⭐

下面程序的输出是什么?

func foo(a []int) {
a = append(a, 1, 2, 3, 4, 5, 6, 7, 8)
a[0] = 200
}

func main() {
a := []int{1, 2}
foo(a)
fmt.Println(a)
}

输出仍是 [1 2],切片 a 没有发生改变。

传参时拷贝了新的切片,因此当新切片的长度发生改变时,原切片并不会发生改变。而且在函数 foo 中,新切片 a 增加了 8 个元素,原切片对应的底层数组不够放置这 8 个元素,因此申请了新的空间来放置扩充后的底层数组。这个时候新切片和原切片指向的底层数组就不是同一个了。因此,对新切片第 0 个元素的修改,并不会影响原切片的第 0 个元素。

如果如果希望 foo 函数的操作能够影响原切片呢?

两种方式:

  • 设置返回值,将新切片返回并赋值给 main 函数中的变量 a。
  • 切片也使用指针方式传参。
func foo(a []int) []int {
a = append(a, 1, 2, 3, 4, 5, 6, 7, 8)
a[0] = 200
return a
}

func main() {
a := []int{1, 2}
a = foo(a)
fmt.Println(a)
}

func foo(a *[]int) {
*a = append(*a, 1, 2, 3, 4, 5, 6, 7, 8)
(*a)[0] = 200
}

func main() {
a := []int{1, 2}
foo(&a)
fmt.Println(a)
}

上述两个程序的输出均为:

[200 2 1 2 3 4 5 6 7 8]

Reference

go语言的 slice切片不是纯引用类型 The 3 ways to sort in Go 极客兔兔-切片(slice)性能及陷阱 Insert a value in a slice at a given index